Skip to content

poc: internal mcp server portals#3003

Draft
simplesagar wants to merge 33 commits into
mainfrom
worktree-portals-spec
Draft

poc: internal mcp server portals#3003
simplesagar wants to merge 33 commits into
mainfrom
worktree-portals-spec

Conversation

@simplesagar
Copy link
Copy Markdown
Member

Summary

Adds Internal MCP Server Portals — a per-project, org-internal catalogue page at app.gram.dev/portal/{project-slug} that lists every MCP server in a project as cards. Project admins toggle it on and brand it (logo, display name, tagline) from project settings. Aggregated install/connect experience over the existing per-server install pages.

Linear: Internal MCP Server Portals
Spec: docs/superpowers/specs/2026-05-20-internal-mcp-server-portals-design.md
Plan: docs/superpowers/plans/2026-05-20-internal-mcp-server-portals.md

What's in this PR

The plan called for three sequential PRs. This single PR bundles all three phases — happy to split into 3 stacked PRs if reviewers prefer (the commit history maps cleanly: see Commits below).

1. Migration (project_portals table)

  • New table per project: enabled, optional display_name/tagline/logo_asset_id overrides, all nullable with consistent empty-string CHECK guards.
  • Defaults to enabled = false — no existing project starts serving a portal silently.

2. Backend (portals Goa service)

  • GET /rpc/portals.get — org-member read. Returns portal config + enriched server cards (joins mcp_serversmcp_endpointstoolsets, includes tool count + install URL).
  • POST /rpc/portals.update — project-admin write. Read-then-merge semantics: nil preserves, "" clears, non-empty sets.
  • Disabled portal returns 404 to org members; project admins can preview via ?preview=true (verified by RBAC test that exercises the project-slug security scheme).
  • No new RBAC scopes — reuses ScopeProjectRead / ScopeProjectWrite.
  • Site URL injected via constructor (no os.Getenv per-call).
  • TS SDK regenerated.

3. Frontend

  • Public-facing route /portal/:projectSlug (PortalPage, PortalHeader, PortalCard) — lazy-loaded, auth-gated by LoginCheck, 404s uniformly on any failure (does not distinguish missing/disabled/wrong-org).
  • In-settings admin section (PortalSettings) — enabled toggle, display-name + tagline inputs, logo upload (reuses ImageUpload), Copy URL button (with toast on success/failure), live preview iframe that reloads on save.
  • Empty state links into the project catalog so users can add servers.

Test Plan

  • Run cd server && go test ./internal/portals/... -v — expect 10 portal tests passing (PASS locally).
  • Run mise build:server — expect clean (PASS locally).
  • Run cd client/dashboard && pnpm tsc -p tsconfig.app.json --noEmit — no new TS errors beyond the 58 pre-existing on main.
  • In the dashboard, open project settings → flip Portal enabled on → set display name + tagline + upload a logo → save → confirm the live preview iframe reflects the change.
  • Copy the portal URL → open in another tab as a logged-in org member → confirm the cards render and "View install" links into the existing per-server install page.
  • Disable the portal in settings → confirm the public URL now returns the generic "Portal not found" page.
  • Sign in as a different org's user → visit the portal URL → confirm a uniform 404 (no leak of project existence).

Explicitly out of scope (deferred)

  • Custom domain hosting (mcp.acme.com root).
  • Public / unlisted visibility modes.
  • Per-server "publish to portal" toggle / curation / sectioning.
  • View analytics.
  • Audit log entry for portal edits.

Commits (logical groups for an optional 3-PR split)

Migration:

  • 120ceadbe feat(db): add project_portals table
  • 6fe5fd655 fix(db): guard tagline against empty string in project_portals
  • 99daebeca docs(portals): align tagline check with implemented migration

Backend + SDK regen:

  • d6169603b feat(portals): add sqlc queries and generated repo
  • a4c379b2a feat(portals): implement getPortal and updatePortal
  • d55edca1f feat(portals): resolve portal logo url with project fallback
  • 382431986 feat(portals): wire portals service into server startup
  • 2c56ae03d test(portals): assert cross-org getPortal returns 404
  • e87d5e0d7 chore(sdk): regen for portals service
  • 8553f6d6e fix(portals): fall back to project logo when portal has no override
  • 1f688cc13 fix(portals): preserve omitted fields in updatePortal partial updates
  • ac3e21dd2 refactor(portals): inject site URL via constructor
  • 355999344 fix(portals): allow empty-string clear for logo_asset_id in update
  • 512850ad3 test(portals): rewrite cross-org test to invoke project-slug security scheme

Frontend:

  • b7c6c5e17 feat(dashboard): add /portal/:slug public-facing portal route
  • 9fdb3f191 feat(dashboard): portal settings section with live preview
  • f65b79385 perf(dashboard): lazy-load portal page route
  • 010d7daaa feat(dashboard): add logo upload to portal settings
  • cbaa5e8c7 fix(dashboard): handle clipboard.writeText errors when copying portal url
  • 7ce7c70ad fix(dashboard): reload portal preview iframe after save
  • de2db74b4 feat(dashboard): link portal empty state to catalog

🤖 Generated with Claude Code

simplesagar and others added 23 commits May 20, 2026 16:50
Draft design for a per-project, org-internal catalogue page that
aggregates a project's MCP servers behind a single Gram-hosted URL.
Adds project_portals table, portals Goa service, project-settings UI,
and a public-facing /portal/{slug} route. Custom-domain hosting, public
visibility modes, curation, and analytics are explicitly deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bite-sized, three-PR plan covering schema migration, Goa service +
SDK regen, and dashboard portal page + settings section. Each task
includes exact files, test code, and commit guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the table that backs the Internal MCP Server Portals
feature. App code consumers ship in a follow-up PR. Default enabled
is false so no portal becomes reachable from this PR alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the empty-string CHECK pattern used by display_name and
by marketplace_name in project_marketplace_settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the logo resolution chain specified in the portals design:
row override → project logo → empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdatePortal previously converted a nil pointer for DisplayName/Tagline
to pgtype.Text{Valid:false}, then unconditionally wrote that NULL into
the column. So a partial update that only flipped enabled=false would
also wipe the stored tagline, display name, and logo override.

Load the existing row first and merge per-field:
  - nil pointer  → preserve existing value
  - &""          → explicit clear (NULL)
  - &"non-empty" → set new value

Also add a focused test for invalid logo_asset_id UUIDs returning 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetPortal previously read GRAM_SITE_URL via os.Getenv on every call
and fell back to a hardcoded production URL when unset, which made
tests silently emit production URLs. Match how other services receive
the site URL: accept it as a NewService argument and pipe the CLI's
existing site-url flag through start.go.

Also assert the install_url is non-empty, contains the endpoint slug,
and uses the configured site URL in TestGetPortal_Enabled_ReturnsServers
now that the value is deterministic in tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PortalPage, PortalHeader, and PortalCard components for the
public-facing MCP server portal. Route is auth-gated via the existing
LoginCheck wrapper but lives outside project-scoped layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PortalSettings component (enabled toggle, display name, tagline,
copy-URL button) mounted in project settings. PortalPreview renders the
portal as an iframe using ?preview=1 so admins can see the live state
before enabling it publicly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defers loading the portal bundle until a user navigates to
/portal/:projectSlug, keeping the main dashboard bundle slim.
Also documents the auth assumption at the route registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires the existing ImageUpload primitive into PortalSettings so admins
can upload, replace, or clear the portal logo. The form initialises
from portal.logoUrl on load and only sends logoAssetId on the mutation
when the user actually changes the asset (empty string clears).

Also memoises PortalPreview since its iframe src only changes with
projectSlug, avoiding unnecessary iframe remounts on parent re-renders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… url

navigator.clipboard.writeText returns a Promise that can reject on
non-HTTPS contexts, missing permissions, or older browsers. Wrap the
call in an async handler with try/catch and surface success/failure
via toast so the user gets feedback either way.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The preview iframe owns its own navigation state, so React Query
cache invalidation does not cause it to re-fetch. Pass a saveCount
key that bumps on every successful save so the iframe is unmounted
and remounted, surfacing the latest portal config immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a project has no MCP servers, the portal empty state now invites
the admin to add servers from the dashboard catalog at
/:orgSlug/projects/:projectSlug/catalog. Falls back to plain text if
the session has no org context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Goa design declared logo_asset_id with Format(FormatUUID). The
generated ValidateUpdatePortalRequestBody rejected "" as not-a-UUID
before reaching the handler, so the "Remove logo" UX (which sends
logo_asset_id: "") returned 422. The handler-side uuid.Parse already
returns BadRequest for malformed non-empty values, so the design-level
validator is redundant; dropping it lets "" through where
mergeLogoAssetID treats it as the documented "clear override" sentinel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… scheme

The previous test had the sibling org querying its own project's portal
(which 404'd because no row existed), so it passed for the wrong reason
and gave false coverage confidence. The real cross-org isolation gate
is the project-slug APIKeyAuth scheme, which rejects requests when the
slug does not belong to the caller's org.

The test now invokes APIKeyAuth directly with the original org's slug
from a sibling-org session, and asserts oops.CodeForbidden (403) — the
actual behaviour of the gate. (My earlier message assumed 404; the
middleware returns 403, and the test was adjusted to match reality.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 22, 2026

🦋 Changeset detected

Latest commit: 298bd92

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
server Minor
dashboard Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment May 24, 2026 1:04am

Request Review

@simplesagar simplesagar added the preview Spawn a preview environment label May 22, 2026
@speakeasybot
Copy link
Copy Markdown
Collaborator

speakeasybot commented May 22, 2026

🚀 Preview Environment (PR #3003)

Preview URL: https://pr-3003.dev.getgram.ai

Component Status Details Updated (UTC)
❌ Database Blocked Image build timed out 2026-05-24 01:39:39.
❌ Images Failed Timed out after 1171s waiting for images 2026-05-24 01:39:37.

Gram Preview Bot

simplesagar and others added 2 commits May 22, 2026 15:01
Will be regenerated with a fresh timestamp after rebasing main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.speakeasy/out.openapi.yaml
#	.speakeasy/workflow.lock
#	client/dashboard/src/pages/settings/Settings.tsx
#	client/sdk/.speakeasy/gen.lock
#	server/cmd/gram/start.go
#	server/database/sqlc.yaml
#	server/gen/http/openapi3.json
#	server/gen/http/openapi3.yaml
#	server/migrations/atlas.sum
@github-actions
Copy link
Copy Markdown
Contributor

atlas migrate lint on server/migrations

Status Step Result
1 new migration file detected 20260522220355_create_project_portals.sql
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

@github-actions
Copy link
Copy Markdown
Contributor

atlas migrate lint on server/clickhouse/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

Addresses two CI failures:
- golangci-lint wrapcheck flagged the bare return of uuid.Parse's error
  from mergeLogoAssetID. Wrapped with context.
- The repo's PR-title-and-changesets hygiene check expected a changeset
  describing this feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@simplesagar simplesagar changed the title feat(portals): Internal MCP Server Portals poc: Internal MCP Server Portals May 23, 2026
@simplesagar simplesagar changed the title poc: Internal MCP Server Portals poc: internal mcp server portals May 23, 2026
Brings the sqlc version header on all repo files back in line with the
version pinned in mise.toml (v1.31.1). My local default sqlc was v1.29.0
so the prior regen downgraded every generated file's header comment.
Pure header-only diff, no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These were authoring artifacts (brainstorming spec + implementation
plan) used to drive the agent-driven build. They aren't intended to
ship as part of the repo's docs; the PR description and code are the
durable record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…route

- PortalSettings was constraining ImageUpload to h-16/w-16, but the
  underlying FullWidthUpload renders a dropzone with p-10 padding that
  ignored the height. The dropzone overflowed the constrained box and
  visually crashed into the tagline / Portal URL / Save controls below
  it. Dropping the override lets the upload component size itself
  naturally.
- /portal/:projectSlug was a lazy import with Suspense fallback={null},
  so a slow chunk load (or any silent lazy failure) rendered a blank
  page — visible to the user as "didn't load". Switched to an eager
  import; the page is small and the saved bytes did not justify the
  black-screen-while-loading UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AuthProvider runs a path-shape redirect chain after session resolves: any
URL whose first segment is not the active org's slug, and where no
project slug is present, gets pushed to the org home (or preferred
project). That logic was eating /portal/<projectSlug> — the user landed
on the home page instead of the portal.

Adding /portal/ to SLUG_EXEMPT_PATHS keeps the portal route reachable
without weakening the redirect for any other URL shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard's QueryClient defaults to throwing every error except 403
to the nearest error boundary. A 404 from /rpc/portals.get (portal
disabled, project not found, or cross-org) was therefore surfacing as
"Something went wrong / resource not found" via the global FullPageError
instead of PortalPage's own "Portal not found" UI.

Opting this query out with throwOnError: false routes 404s back into the
hook's error state where PortalPage already renders the right thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/portal

The original top-level /portal/:projectSlug route forced three opt-outs
from dashboard conventions: SLUG_EXEMPT_PATHS in AuthProvider, an extra
Suspense boundary in App.tsx, and the absence of project context that
SdkProvider expects. Moving the portal under the existing org-scoped
project route eliminates all three:

- The route now lives in routes.tsx as a regular project route with
  outsideMainLayout: true so it still renders without the dashboard
  sidebar (the portal is intentionally a clean catalogue page).
- AuthProvider's SLUG_EXEMPT_PATHS exemption for /portal/ is reverted.
- PortalPreview and the Copy URL helper produce
  /{orgSlug}/projects/{projectSlug}/portal URLs.

The portal URL is longer to share but consistent with the rest of the
dashboard, and the future custom-domain hosting feature (deferred) will
provide the apex-domain short URL anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These leaked into the previous portal-route refactor commit by
accident. The mise.lock change is the well-known URL-only cosmetic
drift (mise.jdx.dev vs mise.en.dev); pnpm-lock.yaml drifted from a
local pnpm install that diverged from main. Reset both to origin/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants